Цель исследования — разобраться, как ведут себя пользователи мобильного приложения стартапа, который продаёт продукты питания. Узнать, как и сколько пользователей доходит до покупки, сколько пользователей «застревает» на предыдущих шагах, на каких именно. Оценить результаты A/A/B-теста и выяснить, какие шрифты в приложении лучше — старые или новые.
Этапы исследования
Получим данные о действиях пользователей и событиях. О качестве данных ничего не известно, поэтому перед исследованием изучим данные и выполним предобработку. Анализ пройдёт в шесть этапов:
Во время исследования изучим:
Описание данных
В нашем распоряжении датасет logs_exp. Каждая запись в логе — это действие пользователя или событие. Структура данных:
EventName — название события;DeviceIDHash — уникальный идентификатор пользователя;EventTimestamp — время события;ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.# импортируем необходимые библиотеки
import pandas as pd
import datetime as dt
import numpy as np
import math as mth
from scipy import stats as st
import matplotlib.pyplot as plt
import seaborn as sns
from plotly import graph_objects as go
from pandas.plotting import register_matplotlib_converters
register_matplotlib_converters()
import warnings
# настроим вывод так, чтобы все числа отражались с двумя знаками после запятой
pd.options.display.float_format = '{:.2f}'.format
# считаем данные из csv-файла и сохраним в переменную
logs = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
# напишем функцию для ознакомления с данными
def hello(df):
print()
print('Первые пять строк датафрейма:')
print('--------------')
display(df.head())
print()
print('Основная информация:')
print('--------------')
df.info()
print()
print('Доля пропусков в данных:')
print('--------------')
display(pd.DataFrame(df.isna().mean().to_frame(name='Пропуски')
.query('Пропуски > 0')['Пропуски'])
.style.background_gradient('coolwarm')
.format({'Пропуски':'{:.1%}'}))
print()
print('Количество явных дубликатов:', df.duplicated().sum())
print('Доля явных дубликатов: {:.2%}'.format(df.duplicated().sum() / len(df) * 100))
# если явные дубликаты присутствуют, удалим их
df_clean = df.drop_duplicates(inplace=True)
print('Количество явных дубликатов после обработки:', df.duplicated().sum())
print('Новое количество строк в датафрейме:', len(df))
print('--------------')
# применим к датафрейму logs функцию hello
hello(logs)
Первые пять строк датафрейма: --------------
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
Основная информация: -------------- <class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB Доля пропусков в данных: --------------
| Пропуски |
|---|
Количество явных дубликатов: 413 Доля явных дубликатов: 16.92% Количество явных дубликатов после обработки: 0 Новое количество строк в датафрейме: 243713 --------------
Явные дубликаты удалены. Нет никаких оснований полагать, что в данных присутствуют неявные дубликаты.
# приведём названия колонок к стилю snake_case
logs = logs.rename(
columns={
'EventName': 'event_name',
'DeviceIDHash': 'device_id_hash',
'EventTimestamp': 'date_time',
'ExpId': 'exp_id'
}
)
# проверим результат
logs.columns
Index(['event_name', 'device_id_hash', 'date_time', 'exp_id'], dtype='object')
# приведём к формату даты и времени колонку 'date_time'
logs['date_time'] = pd.to_datetime(logs['date_time'], unit='s')
# проверим результат
logs.dtypes
event_name object device_id_hash int64 date_time datetime64[ns] exp_id int64 dtype: object
# выделим даты в отдельную колоку 'date'
logs['date'] = logs['date_time'].astype('datetime64[D]')
# проверим результат
logs.head()
| event_name | device_id_hash | date_time | exp_id | date | |
|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 2019-07-25 04:43:36 | 246 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 2019-07-25 11:11:42 | 246 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 2019-07-25 11:28:47 | 248 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 2019-07-25 11:48:42 | 248 | 2019-07-25 |
logs 4 колонки и 244 126 строк. Пропусков нет.snake_case.date_time. В остальных колонках типы данных соответствуют представленным значениям.# узнаем, сколько всего событий в логе
pd.Series(logs['event_name'].unique())
0 MainScreenAppear 1 PaymentScreenSuccessful 2 CartScreenAppear 3 OffersScreenAppear 4 Tutorial dtype: object
# посмотрим, сколько всего пользователей в логе
print(
'Количество уникальных пользователей в логе:',\
logs['device_id_hash'].nunique()
)
Количество уникальных пользователей в логе: 7551
В логе 7551 уникальный пользователь. Всего 5 событий:
MainScreenAppear — пользователь увидел главную страницу,PaymentScreenSuccessful — оплатил покупку,CartScreenAppear — перешёл в корзину,OffersScreenAppear — посмотрел блок с предложениями,Tutorial — открыл руководство.# определим, сколько событий приходится на пользователя
event_cnt_per_user = logs.groupby(
'device_id_hash', as_index=False).agg({'event_name': 'count'}
)
# переименуем колонку с количеством событий на пользователя
event_cnt_per_user = (
event_cnt_per_user.rename(columns={'event_name': 'event_cnt'})
)
# выведем первые 5 строк
display(event_cnt_per_user.head())
# изучим описательную статистику количества событий на пользователя
display(event_cnt_per_user['event_cnt'].describe())
| device_id_hash | event_cnt | |
|---|---|---|
| 0 | 6888746892508752 | 1 |
| 1 | 6909561520679493 | 5 |
| 2 | 6922444491712477 | 47 |
| 3 | 7435777799948366 | 6 |
| 4 | 7702139951469979 | 137 |
count 7551.00 mean 32.28 std 65.15 min 1.00 25% 9.00 50% 20.00 75% 37.00 max 2307.00 Name: event_cnt, dtype: float64
Видим наличие выбросов — при медиане равной 20 максимальное количество событий на пользователя составляет 2307, что выглядит неправдоподобно. Построим точечный график.
# зададим стиль и палитру графиков
sns.set_style('darkgrid')
sns.set_palette('deep')
# серия из чисел от 0 до количества наблюдений в event_name
x_values = pd.Series(range(0, len(event_cnt_per_user['event_cnt'])))
# зададим размер сетки для графика
plt.figure(figsize=(13, 7))
# построим точечную диаграмму количества событий на пользователя
plt.scatter(x_values, event_cnt_per_user['event_cnt'], alpha=0.5)
# настроим отображение графика
plt.title('График количества событий на пользователя', fontsize=14)
plt.xlabel('Номера наблюдений', fontsize=12)
plt.ylabel('Количество события', fontsize=12);
Визуализация подтверждает предположение по наличию выбросов. У большинства пользователей события не превышают и сотни, не говоря уже о тысячах. Посчитаем перцентили, чтобы убрать из анализа аномальных пользователей.
np.percentile(event_cnt_per_user['event_cnt'], [90, 95, 99])
array([ 64. , 89. , 200.5])
Не более 5% пользователей совершают более 89 событий. И не более 1% пользователей — более 200. Примем за аномальных пользователей тех, кто совершил более 89 событий.
print(
'Количество уникальных пользователей в логе:',
logs['device_id_hash'].nunique()
)
# создадим список с аномальными пользователями
abnormal_users = event_cnt_per_user.query(
'event_cnt > event_cnt.quantile(.95)'
)
abnormal_users_list = list(abnormal_users['device_id_hash'])
print(
'Количество аномальных пользователей:',
len(abnormal_users),
', доля составляет {:.1%}'.format(
len(abnormal_users) / logs['device_id_hash'].nunique()
)
)
# очистим от аномалий датафрейм logs
logs = logs.query('device_id_hash not in @abnormal_users_list')
print(
'Количество уникальных пользователей после удаления аномальных:',
logs['device_id_hash'].nunique()
)
Количество уникальных пользователей в логе: 7551 Количество аномальных пользователей: 372 , доля составляет 4.9% Количество уникальных пользователей после удаления аномальных: 7179
# очистим от аномалий датафрейм с количеством событий на пользователя
event_cnt_per_user = event_cnt_per_user.query(
'device_id_hash not in @abnormal_users_list'
)
# изучим описательную статистику количества событий на пользователя
event_cnt_per_user['event_cnt'].describe()
count 7179.00 mean 24.06 std 19.43 min 1.00 25% 9.00 50% 18.00 75% 34.00 max 89.00 Name: event_cnt, dtype: float64
На данном этапе обнаружили 372 аномальных пользователя, совершивших более 89 событий. Их доля составляет 4,9%. После очищения данных от аномалий возьмём медианное значение для определения среднего количества событий. Таким образом, в среднем на пользователя приходится 18 событий.
# найдём максимальную и минимальную дату проведения эксперимента
print(
'Минимальная дата проведения эксперимента:',\
logs['date_time'].min()
)
print(
'Максимальная дата проведения эксперимента:',\
logs['date_time'].max()
)
Минимальная дата проведения эксперимента: 2019-07-25 04:43:36 Максимальная дата проведения эксперимента: 2019-08-07 21:15:17
# зададим размер сетки для графика
plt.figure(figsize=(13, 7))
# построим гистограмму
plt.hist(logs['date_time'], bins=14*24)
# настроим отображение графика
plt.title('Период проведения эксперимента', fontsize=14)
plt.xlabel('Дата', fontsize=12)
plt.ylabel('Количество события', fontsize=12)
plt.xticks(rotation=30, ha='right');
Период проведения эксперимента — с 25 июля по 7 августа 2019 года. По визуализации видим, что данные до 1 августа неполные, поэтому уберём их из анализа.
print(
'Количество полных и неполных данных:',
len(logs)
)
print(
'Количество неполных данных:',
len(logs.query('date < "2019-08-01"')),
', доля составляет {:.1%}'.format(
len(logs.query('date < "2019-08-01"')) / len(logs)
)
)
# создадим датафрейм, отбросив неполные данные
logs_filtered = logs.query('date >= "2019-08-01"')
print(
'Количество полных данных после фильтрации:',
len(logs_filtered)
)
print()
print(
'Количество уникальных пользователей до фильтрации:',
logs['device_id_hash'].nunique()
)
print(
'Количество уникальных пользователей, которые мы потеряли:',
logs['device_id_hash'].nunique()\
- logs_filtered['device_id_hash'].nunique(),
', доля составляет {:.1%}'.format(
(logs['device_id_hash'].nunique()\
- logs_filtered['device_id_hash'].nunique())
/ logs['device_id_hash'].nunique()
)
)
print(
'Количество уникальных пользователей после фильтрации:',
logs_filtered['device_id_hash'].nunique()
)
Количество полных и неполных данных: 172741 Количество неполных данных: 2399 , доля составляет 1.4% Количество полных данных после фильтрации: 170342 Количество уникальных пользователей до фильтрации: 7179 Количество уникальных пользователей, которые мы потеряли: 17 , доля составляет 0.2% Количество уникальных пользователей после фильтрации: 7162
# зададим размер сетки для графика
plt.figure(figsize=(13, 7))
# построим гистограмму по полным данным
plt.hist(logs_filtered['date_time'], bins=7*24)
# настроим отображение графика
plt.title(
'Период проведения эксперимента по полным данным',\
fontsize=14
)
plt.xlabel('Дата', fontsize=12)
plt.ylabel('Количество события', fontsize=12)
plt.xticks(rotation=30, ha='right');
На данном этапе обнаружили 1.4% неполных данных. Отбросив их, выяснили, на самом деле располагаем данными за неделю — с 1 по 7 августа 2019 года.
Если пользователь видит разные версии исследуемой страницы в ходе одного исследования, неизвестно, какая именно повлияла на его решения. Значит, и результаты такого теста нельзя интерпретировать однозначно. Поэтому проверим, есть ли такие пользователи. При обнаружении удалим их.
# проверим выборки 246 и 247 на пересечения
id_a1a2 = np.intersect1d(
logs_filtered.query('exp_id == 246')['device_id_hash'].unique(),
logs_filtered.query('exp_id == 247')['device_id_hash'].unique()
)
print(
'Количество уникальных id, которые вошли в обе контрольные группы 246 и 247:',\
len(id_a1a2)
)
# проверим выборки 246 и 248 на пересечения
id_a1b = np.intersect1d(
logs_filtered.query('exp_id == 246')['device_id_hash'].unique(),
logs_filtered.query('exp_id == 248')['device_id_hash'].unique()
)
print(
'Количество уникальных id, которые вошли в обе контрольные группы 246 и 248:',\
len(id_a1b)
)
# проверим выборки 247 и 248 на пересечения
id_a2b = np.intersect1d(
logs_filtered.query('exp_id == 247')['device_id_hash'].unique(),
logs_filtered.query('exp_id == 248')['device_id_hash'].unique()
)
print(
'Количество уникальных id, которые вошли в обе контрольные группы 247 и 248:',\
len(id_a2b)
)
Количество уникальных id, которые вошли в обе контрольные группы 246 и 247: 0 Количество уникальных id, которые вошли в обе контрольные группы 246 и 248: 0 Количество уникальных id, которые вошли в обе контрольные группы 247 и 248: 0
# убедимся в достоверности проверок
(
logs_filtered
.groupby('device_id_hash')
.agg({'exp_id': 'nunique'})
.query('exp_id > 1')
)
| exp_id | |
|---|---|
| device_id_hash |
# проверим распределение пользователей по трём группам
(
logs_filtered
.groupby('exp_id', as_index=False)
.agg({'device_id_hash': 'nunique'})
)
| exp_id | device_id_hash | |
|---|---|---|
| 0 | 246 | 2363 |
| 1 | 247 | 2395 |
| 2 | 248 | 2404 |
Количество пользователей в контрольных группах и экспериментальной сопоставимо.
# отсортируем события по частоте
# поcтроим столбчатую диаграмму
event_cnt = (
logs_filtered
.groupby('event_name')
.agg({'device_id_hash': 'count'})
.sort_values('device_id_hash', ascending=False)
).plot(
kind='bar',
figsize=(13, 7)
);
# настроим отображение графика
plt.title('Распределение событий в логах', fontsize=14)
plt.xlabel('Название события', fontsize=12)
plt.ylabel('Количество события', fontsize=12)
plt.xticks(rotation=0);
for i in event_cnt.patches:
event_cnt.annotate(
np.round(i.get_height(), decimals=2),
(i.get_x()+i.get_width()/2., i.get_height()),
ha='center',
va='bottom'
);
# посмотрим доли событий по круговой диаграмме
plt.rcParams["figure.figsize"] = (10, 10)
plt.title('Доли событий в логах', fontsize=14)
values = logs_filtered['event_name'].value_counts(normalize=True)
names = list(logs_filtered['event_name'].unique())
plt.pie(
values,
labels=names,
labeldistance=1.05,
autopct='%0.1f%%',
pctdistance=0.9,
wedgeprops = {'linewidth': 0.8, 'edgecolor': 'white'}
);
Напомним, всего 5 событий. Расположим их в порядке убывания частоты.
MainScreenAppear — пользователь увидел главную страницу — 58%,OffersScreenAppear — посмотрел блок с предложениями — 18.8%,CartScreenAppear — перешёл в корзину — 12.9%,PaymentScreenSuccessful — оплатил покупку — 9.7%,Tutorial — открыл руководство — 0.5%.Чаще всего встречается событие «Пользователь увидел главную страницу» — 58%, реже всего «Открыл руководство» — 0.5%.
# отсортируем события по числу уникальных пользователей
# поcтроим столбчатую диаграмму
event_user_cnt = (
logs_filtered
.groupby('event_name')
.agg({'device_id_hash': 'nunique'})
.sort_values('device_id_hash', ascending=False)
).plot(
kind='bar',
figsize=(13, 7)
);
# настроим отображение графика
plt.title('События по числу уникальных пользователей', fontsize=14)
plt.xlabel('Название события', fontsize=12)
plt.ylabel('Количество пользователей', fontsize=12)
plt.xticks(rotation=0);
for i in event_user_cnt.patches:
event_user_cnt.annotate(
np.round(i.get_height(), decimals=2),
(i.get_x()+i.get_width()/2., i.get_height()),
ha='center',
va='bottom'
);
# выделим доли уникальных пользователей, которые хоть раз совершали событие
share_of_users = (
logs_filtered
.groupby('event_name', as_index=False)
.agg({'device_id_hash': 'nunique'})
.sort_values('device_id_hash', ascending=False)
)
share_of_users['share_of_initial_%'] = (
share_of_users['device_id_hash']\
/ logs_filtered['device_id_hash'].nunique() * 100
)
# проверим результат
share_of_users
| event_name | device_id_hash | share_of_initial_% | |
|---|---|---|---|
| 1 | MainScreenAppear | 7052 | 98.46 |
| 2 | OffersScreenAppear | 4236 | 59.15 |
| 0 | CartScreenAppear | 3387 | 47.29 |
| 3 | PaymentScreenSuccessful | 3197 | 44.64 |
| 4 | Tutorial | 774 | 10.81 |
# отсортируем датафрейм по возрастанию
share_of_users_sort = share_of_users.sort_values('share_of_initial_%')
share_of_users_sort_list = list(round(share_of_users_sort['share_of_initial_%'], 1))
# поcтроим столбчатую диаграмму
fig, ax = plt.subplots(figsize=(13, 7))
plt.barh(y=share_of_users_sort['event_name'],\
width=share_of_users_sort['share_of_initial_%'])
# настроим отображение графика
plt.title(
'Доли уникальных пользователей, которые хоть раз совершали событие',\
fontsize=14
)
plt.xlabel('Доля события, %', fontsize=12)
plt.ylabel('Название события', fontsize=12)
for i, v in enumerate(share_of_users_sort_list):
ax.text(v + .25, i, str(v))
Расположим события в порядке убывания доли уникальных пользователей, которые хоть раз совершали это событие, относительно общего числа уникальных пользователей.
Отметим, доля события «Открыл руководство» — всего 10.8%, и оно не выстраивается в последовательную цепочку. При расчёте воронки учитывать это событие не будем. Порядок остальных событий выглядят правдоподобно: пользователь увидел главную страницу → посмотрел блок с предложениями → перешёл в корзину → оплатил покупку. Однако пользователь может пропустить некоторые этапы, например, перейдя с главной страницы сразу в корзину.
# уберём событие «Открыл руководство» из датафрейма share_of_users
share_of_users_filtered = (
share_of_users.query('event_name != "Tutorial"').reset_index(drop=True)
)
# добавим колонку, где отобразим долю пользователей,
# переходящих на следующий шаг воронки, от числа пользователей на предыдущем шаге
share_of_users_filtered['share_of_previous_%'] = (
share_of_users_filtered['device_id_hash']
/ share_of_users_filtered['device_id_hash']
.shift(fill_value=share_of_users_filtered['device_id_hash'][0]) * 100
)
# проверим результат
share_of_users_filtered
| event_name | device_id_hash | share_of_initial_% | share_of_previous_% | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 7052 | 98.46 | 100.00 |
| 1 | OffersScreenAppear | 4236 | 59.15 | 60.07 |
| 2 | CartScreenAppear | 3387 | 47.29 | 79.96 |
| 3 | PaymentScreenSuccessful | 3197 | 44.64 | 94.39 |
# визуализируем воронку событий
fig = go.Figure(go.Funnel(
x = share_of_users_filtered['device_id_hash'],
y = share_of_users_filtered['event_name'],
textinfo = 'value+percent initial+percent previous'
)
)
fig.update_layout(
title_text='Воронка событий по действиям пользователей',\
title_x=0.75
)
fig.show()
# уберём событие «Открыл руководство» из датафрейма logs_filtered
logs_final = logs_filtered.query('event_name != "Tutorial"')
# создадим датафрейм с распределением пользователей по трём группам
group_cnt = (
logs_final
.groupby('exp_id', as_index=False)
.agg({'device_id_hash': 'nunique'})
)
# переименуем колонку с количеством пользователей в группах
exp_id_cnt = group_cnt.rename(columns={'device_id_hash': 'group_cnt'})
# добавим количество пользователей объединённой контрольной группы 246+247
A1_A2 = (
{'exp_id': '246+247',
'group_cnt': exp_id_cnt['group_cnt'][0] + exp_id_cnt['group_cnt'][1]}
)
exp_id_cnt = exp_id_cnt.append(A1_A2, ignore_index=True)
# проверим результат
exp_id_cnt
| exp_id | group_cnt | |
|---|---|---|
| 0 | 246 | 2362 |
| 1 | 247 | 2394 |
| 2 | 248 | 2402 |
| 3 | 246+247 | 4756 |
# отобразим число пользователей по каждому событию для всех групп
logs_final_groups = logs_final.pivot_table(
index='event_name',
values='device_id_hash',
columns='exp_id',
aggfunc='nunique'
).reset_index()
# настроим отображение колонок
logs_final_groups = logs_final_groups.rename_axis(None, axis=1)
# добавим колонку с суммарным количеством пользователей трёх групп по каждому событию
logs_final_groups['total'] = logs_final_groups[[246, 247, 248]].sum(axis=1)
# добавим колонку с количеством пользователей в объединённой контрольной группе 246+247
logs_final_groups['246+247'] = logs_final_groups[246] + logs_final_groups[247]
# отсортируем значения в порядке уменьшения
logs_final_groups = logs_final_groups.sort_values(
'total',
ascending=False
).reset_index(drop=True)
# проверим результат
logs_final_groups
| event_name | 246 | 247 | 248 | total | 246+247 | |
|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 2330 | 2361 | 2361 | 7052 | 4691 |
| 1 | OffersScreenAppear | 1425 | 1408 | 1403 | 4236 | 2833 |
| 2 | CartScreenAppear | 1152 | 1130 | 1105 | 3387 | 2282 |
| 3 | PaymentScreenSuccessful | 1087 | 1052 | 1058 | 3197 | 2139 |
# визуализируем воронку событий
fig = go.Figure()
fig.add_trace(go.Funnel(
name = 'group 246',
x = logs_final_groups[246],
y = logs_final_groups['event_name'],
textinfo = 'value+percent initial+percent previous'
)
)
fig.add_trace(go.Funnel(
name = 'group 247',
x = logs_final_groups[247],
y = logs_final_groups['event_name'],
textinfo = 'value+percent initial+percent previous'
)
)
fig.add_trace(go.Funnel(
name = 'group 248',
x = logs_final_groups[248],
y = logs_final_groups['event_name'],
textinfo = 'value+percent initial+percent previous',
textposition = 'inside'
)
)
fig.update_layout(
title_text='Воронка событий по действиям пользователей',\
title_x=0.75
)
fig.show()
По воронке видно, количество пользователей в группах на каждом шаге сопоставимо.
def z_test(successes, trials, alpha = .05):
# пропорция успехов в первой группе
p1 = successes[0] / trials[0]
# пропорция успехов во второй группе
p2 = successes[1] / trials[1]
# пропорция успехов в комбинированном датасете
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# статистика в стандартных отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1 / trials[0] + 1 / trials[1]))
# стандартное нормальное распределение (среднее 0, стандартное отклонение 1)
distr = st.norm(0, 1)
# так как распределение статистики нормальное, вызовем метод cdf()
# тест двусторонний — удваиваем результат, а статистику возьмём по модулю методом abs(),
# чтобы получить правильный результат независимо от её знака
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение:', p_value)
if p_value < alpha:
print(
'Отвергаем нулевую гипотезу: между долями уникальных посетителей, побывавших на этапе воронки, есть значимая разница.'
)
else:
print(
'Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы.'
)
Проверим, находят ли статистические критерии при критическом уровне статистической значимости alpha = 0.05 разницу между контрольными группами 246 и 247. Перед проведением z-теста сформулируем гипотезы.
print('Индекс группы 246:', exp_id_cnt[exp_id_cnt['exp_id'] == 246].index)
print('Индекс группы 247:', exp_id_cnt[exp_id_cnt['exp_id'] == 247].index)
Индекс группы 246: Int64Index([0], dtype='int64') Индекс группы 247: Int64Index([1], dtype='int64')
logs_final_groups = logs_final_groups.reset_index()
# количество уникальных пользователей в группах 246 и 247
trials = np.array([exp_id_cnt['group_cnt'][0], exp_id_cnt['group_cnt'][1]])
# напишем цикл, в котором для каждого события применим функцию проведения z-теста
for index, row in logs_final_groups.iterrows():
print(row['event_name'])
successes = np.array([row[246], row[247]])
z_test(successes, trials)
print('')
MainScreenAppear p-значение: 0.943976742159512 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. OffersScreenAppear p-значение: 0.28664206156215766 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. CartScreenAppear p-значение: 0.2782742320427256 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. PaymentScreenSuccessful p-значение: 0.14994529688075264 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы.
Z-тест показал, при критическом уровне статистической значимости alpha = 0.05 между контрольными группами 246 и 247 статистически значимых различий нет по всем событиям. Доли уникальных посетителей, побывавших всех этапах воронки, одинаковы. Можно сказать, что разбиение на группы работает корректно.
Проверим, находят ли статистические критерии при критическом уровне статистической значимости alpha = 0.05 разницу между контрольной группой 246 и экспериментальной группой 248. Перед проведением z-теста сформулируем гипотезы.
print('Индекс группы 246:', exp_id_cnt[exp_id_cnt['exp_id'] == 246].index)
print('Индекс группы 248:', exp_id_cnt[exp_id_cnt['exp_id'] == 248].index)
Индекс группы 246: Int64Index([0], dtype='int64') Индекс группы 248: Int64Index([2], dtype='int64')
# количество уникальных пользователей в группах 246 и 248
trials = np.array([exp_id_cnt['group_cnt'][0], exp_id_cnt['group_cnt'][2]])
# цикл, в котором для каждого события применим функцию проведения z-теста
for index, row in logs_final_groups.iterrows():
print(row['event_name'])
successes = np.array([row[246], row[248]])
z_test(successes, trials)
print('')
MainScreenAppear p-значение: 0.3225283265855481 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. OffersScreenAppear p-значение: 0.17719916379035094 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. CartScreenAppear p-значение: 0.05565724679707862 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. PaymentScreenSuccessful p-значение: 0.17099209267877957 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы.
Z-тест показал, при критическом уровне статистической значимости alpha = 0.05 между контрольной группой 246 и экспериментальной группой 248 статистически значимых различий нет по всем событиям. Доли уникальных посетителей, побывавших всех этапах воронки, одинаковы.
Проверим, находят ли статистические критерии при критическом уровне статистической значимости alpha = 0.05 разницу между контрольной группой 247 и экспериментальной группой 248. Перед проведением z-теста сформулируем гипотезы.
print('Индекс группы 247:', exp_id_cnt[exp_id_cnt['exp_id'] == 247].index)
print('Индекс группы 248:', exp_id_cnt[exp_id_cnt['exp_id'] == 248].index)
Индекс группы 247: Int64Index([1], dtype='int64') Индекс группы 248: Int64Index([2], dtype='int64')
# количество уникальных пользователей в группах 247 и 248
trials = np.array([exp_id_cnt['group_cnt'][1], exp_id_cnt['group_cnt'][2]])
# цикл, в котором для каждого события применим функцию проведения z-теста
for index, row in logs_final_groups.iterrows():
print(row['event_name'])
successes = np.array([row[247], row[248]])
z_test(successes, trials)
print('')
MainScreenAppear p-значение: 0.35612190724510806 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. OffersScreenAppear p-значение: 0.776367288993445 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. CartScreenAppear p-значение: 0.4056464780326059 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. PaymentScreenSuccessful p-значение: 0.942478383416967 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы.
Z-тест показал, при критическом уровне статистической значимости alpha = 0.05 между контрольной группой 247 и экспериментальной группой 248 статистически значимых различий нет по всем событиям. Доли уникальных посетителей, побывавших всех этапах воронки, одинаковы.
Проверим, находят ли статистические критерии при критическом уровне статистической значимости alpha = 0.05 разницу между объединённой контрольной группой 246+247 и экспериментальной группой 248. Перед проведением z-теста сформулируем гипотезы.
print(
'Индекс группы 246+247:',\
exp_id_cnt[exp_id_cnt['exp_id'] == '246+247'].index
)
print(
'Индекс группы 248:',\
exp_id_cnt[exp_id_cnt['exp_id'] == 248].index
)
Индекс группы 246+247: Int64Index([3], dtype='int64') Индекс группы 248: Int64Index([2], dtype='int64')
# количество уникальных пользователей в группах 246+247 и 248
trials = np.array([exp_id_cnt['group_cnt'][3], exp_id_cnt['group_cnt'][2]])
# цикл, в котором для каждого события применим функцию проведения z-теста
for index, row in logs_final_groups.iterrows():
print(row['event_name'])
successes = np.array([row['246+247'], row[248]])
z_test(successes, trials)
print('')
MainScreenAppear p-значение: 0.26048293215606 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. OffersScreenAppear p-значение: 0.3469207058418755 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. CartScreenAppear p-значение: 0.11346356018477599 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы. PaymentScreenSuccessful p-значение: 0.45576638375829726 Не получилось отвергнуть нулевую гипотезу. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы.
Несколько сравнений, проводимых на одних и тех же данных — это множественный тест. Его важная особенность в том, что с каждой новой проверкой гипотезы растёт вероятность ошибки первого рода. При попарном сравнении вероятность, что тест покажет ложнопозитивный результат равна уровню значимости.
Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, воспользуемся методом Бонферрони: уровни значимости в каждом из m сравнений в m раз меньше, чем уровень значимости, требуемый при единственном сравнении. Проще говоря, разделим уровень значимости 0.05 на 16 гипотез и получим критический уровень статистической значимости alpha = 0.003125. Так как и без поправок нет различий, то и меньшем alpha тоже не будет.
Z-тест показал, при критическом уровне статистической значимости alpha = 0.05 между объединённой контрольной группой 246+247 и экспериментальной группой 248 статистически значимых различий нет по всем событиям. Доли уникальных посетителей, побывавших всех этапах воронки, одинаковы. Аналогично результатам сравнения экспериментальной группы с каждой из контрольных групп в отдельности по каждому событию.
На данном шаге было проведено 16 проверок статистических гипотез. При критическом уровне статистической значимости alpha = 0.05 статистически значимых различий по каждому событию между группами нет. Доли уникальных посетителей, побывавших на этапе воронки, одинаковы.
Всего 5 событий. Чаще всего встречается событие «Пользователь увидел главную страницу» — 58%, реже всего «Открыл руководство» — 0.5%. События расположены в порядке убывания доли уникальных пользователей, которые хоть раз совершали это событие, относительно общего числа уникальных пользователей:
Применяя Z-тест, было проведено 16 проверок статистических гипотез. При критическом уровне статистической значимости alpha = 0.05 статистически значимых различий по каждому событию между всеми группами нет.
Резюмируя, мы разобрались, как ведут себя пользователи мобильного приложения стартапа, который продаёт продукты питания. Узнали, как и сколько пользователей доходит до покупки, сколько пользователей «застревает» на предыдущих шагах, на каких именно. Изучили результаты A/A/B-теста и выяснили, что в контрольных группах со старыми шрифтами и экспериментальной группе с новыми шрифтами статистически значимых различий нет. Нет причин волноваться, что пользователям будет непривычно. Можно рекомендовать дизайнерам менять шрифты на новые.